| 1 | import Link from "next/link"; |
| 2 | import { SiteFooter } from "@/components/site-footer"; |
| 3 | import { notFound } from "next/navigation"; |
| 4 | import { revalidatePath } from "next/cache"; |
| 5 | import { serverCaller } from "@/lib/trpc/server"; |
| 6 | |
| 7 | export const dynamic = "force-dynamic"; |
| 8 | |
| 9 | function youtubeId(url: string): string | null { |
| 10 | try { |
| 11 | const u = new URL(url); |
| 12 | const host = u.hostname.replace(/^www\./, ""); |
| 13 | if (host === "youtu.be") { |
| 14 | const id = u.pathname.slice(1); |
| 15 | return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null; |
| 16 | } |
| 17 | if (host === "youtube.com" || host === "m.youtube.com") { |
| 18 | if (u.pathname === "/watch") { |
| 19 | const id = u.searchParams.get("v"); |
| 20 | return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null; |
| 21 | } |
| 22 | const m = u.pathname.match(/^\/(?:embed|shorts|live)\/([a-zA-Z0-9_-]{11})/); |
| 23 | if (m) return m[1]; |
| 24 | } |
| 25 | return null; |
| 26 | } catch { |
| 27 | return null; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | export default async function LivePage({ |
| 32 | params, |
| 33 | }: { |
| 34 | params: Promise<{ slug: string }>; |
| 35 | }) { |
| 36 | const { slug } = await params; |
| 37 | const caller = serverCaller(); |
| 38 | const city = await caller.city.bySlug({ slug }); |
| 39 | if (!city) notFound(); |
| 40 | |
| 41 | const events = await caller.event.list({ cityId: city.id }); |
| 42 | const live = events.filter((e) => e.isLive); |
| 43 | |
| 44 | async function goLive(formData: FormData) { |
| 45 | "use server"; |
| 46 | const id = String(formData.get("eventId") ?? ""); |
| 47 | const liveUrl = String(formData.get("liveUrl") ?? "").trim(); |
| 48 | if (!id || !liveUrl) return; |
| 49 | await serverCaller().event.setLive({ id, isLive: true, liveUrl }); |
| 50 | revalidatePath(`/city/${slug}/live`); |
| 51 | revalidatePath(`/city/${slug}`); |
| 52 | } |
| 53 | |
| 54 | async function endLive(formData: FormData) { |
| 55 | "use server"; |
| 56 | const id = String(formData.get("eventId") ?? ""); |
| 57 | if (!id) return; |
| 58 | await serverCaller().event.setLive({ id, isLive: false }); |
| 59 | revalidatePath(`/city/${slug}/live`); |
| 60 | revalidatePath(`/city/${slug}`); |
| 61 | } |
| 62 | |
| 63 | return ( |
| 64 | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> |
| 65 | <Link href={`/city/${slug}`} className="kicker hover:text-gold"> |
| 66 | ← {city.name} |
| 67 | </Link> |
| 68 | |
| 69 | <p className="kicker mt-10 flex items-center gap-3 text-gold"> |
| 70 | <span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-gold" /> |
| 71 | Live · {city.metroLabel} |
| 72 | </p> |
| 73 | <h1 className="display mt-3 text-7xl sm:text-8xl">Live</h1> |
| 74 | <p className="serif mt-5 max-w-xl text-xl text-ash"> |
| 75 | Catch a set live from {city.name}, or put your own set on. Drop a |
| 76 | YouTube or Facebook live link and it shows up here. |
| 77 | </p> |
| 78 | |
| 79 | {} |
| 80 | <section className="mt-16 border-t border-[var(--line)] pt-10"> |
| 81 | <div className="flex items-end justify-between"> |
| 82 | <h2 className="display text-3xl">On Now</h2> |
| 83 | <span className="kicker text-ash"> |
| 84 | {String(live.length).padStart(2, "0")} live |
| 85 | </span> |
| 86 | </div> |
| 87 | |
| 88 | {live.length === 0 ? ( |
| 89 | <p className="serif mt-8 text-xl text-ash"> |
| 90 | Nobody is live right now. Start a stream below and the set goes on |
| 91 | here. |
| 92 | </p> |
| 93 | ) : ( |
| 94 | <ul className="mt-8 flex flex-col gap-12"> |
| 95 | {live.map((e) => { |
| 96 | const ytId = e.liveUrl ? youtubeId(e.liveUrl) : null; |
| 97 | return ( |
| 98 | <li key={e.id}> |
| 99 | <div className="flex items-end justify-between gap-5"> |
| 100 | <h3 className="display text-2xl sm:text-3xl">{e.title}</h3> |
| 101 | <span className="kicker flex items-center gap-2 text-gold"> |
| 102 | <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-gold" /> |
| 103 | LIVE |
| 104 | </span> |
| 105 | </div> |
| 106 | |
| 107 | {ytId ? ( |
| 108 | <div className="mt-5 aspect-video w-full overflow-hidden border border-[var(--line)]"> |
| 109 | <iframe |
| 110 | src={`https://www.youtube-nocookie.com/embed/${ytId}`} |
| 111 | title={e.title} |
| 112 | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" |
| 113 | allowFullScreen |
| 114 | className="h-full w-full" |
| 115 | /> |
| 116 | </div> |
| 117 | ) : e.liveUrl ? ( |
| 118 | <a |
| 119 | href={e.liveUrl} |
| 120 | target="_blank" |
| 121 | rel="noopener noreferrer" |
| 122 | className="kicker mt-5 inline-block bg-gold px-6 py-3 text-stage hover:bg-gold-bright" |
| 123 | > |
| 124 | Watch live → |
| 125 | </a> |
| 126 | ) : null} |
| 127 | |
| 128 | <form action={endLive} className="mt-4"> |
| 129 | <input type="hidden" name="eventId" value={e.id} /> |
| 130 | <button |
| 131 | type="submit" |
| 132 | className="kicker text-ash hover:text-bone" |
| 133 | > |
| 134 | End stream |
| 135 | </button> |
| 136 | </form> |
| 137 | </li> |
| 138 | ); |
| 139 | })} |
| 140 | </ul> |
| 141 | )} |
| 142 | </section> |
| 143 | |
| 144 | {} |
| 145 | <section className="mt-16 border-t border-[var(--line)] pt-10"> |
| 146 | <div className="flex items-end justify-between"> |
| 147 | <h2 className="display text-3xl">Go Live</h2> |
| 148 | <span className="kicker">Put a set on</span> |
| 149 | </div> |
| 150 | |
| 151 | {events.length === 0 ? ( |
| 152 | <p className="serif mt-8 text-xl text-ash"> |
| 153 | Post a set on the city page first, then come back to take it live. |
| 154 | </p> |
| 155 | ) : ( |
| 156 | <form action={goLive} className="mt-6 flex flex-col gap-3"> |
| 157 | <select |
| 158 | name="eventId" |
| 159 | required |
| 160 | defaultValue="" |
| 161 | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" |
| 162 | > |
| 163 | <option value="" disabled className="bg-stage"> |
| 164 | CHOOSE A SET |
| 165 | </option> |
| 166 | {events.map((e) => ( |
| 167 | <option key={e.id} value={e.id} className="bg-stage"> |
| 168 | {e.title.toUpperCase()} |
| 169 | </option> |
| 170 | ))} |
| 171 | </select> |
| 172 | <input |
| 173 | name="liveUrl" |
| 174 | required |
| 175 | type="url" |
| 176 | placeholder="Live stream URL (YouTube or Facebook live)" |
| 177 | className="serif border-b border-[var(--line)] bg-transparent py-3 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" |
| 178 | /> |
| 179 | <div className="mt-2"> |
| 180 | <button |
| 181 | type="submit" |
| 182 | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" |
| 183 | > |
| 184 | Go live |
| 185 | </button> |
| 186 | </div> |
| 187 | </form> |
| 188 | )} |
| 189 | </section> |
| 190 | <SiteFooter /> |
| 191 | </main> |
| 192 | ); |
| 193 | } |